1. 基础部分
1.1 条款1: 视C++为一个语言联邦
C++语言的四个层次:
- C。没有C++的面向对象,没有模板,没有异常,没有重载等。
- Object-Oriented C++。也就是C with Classes。classes、封装、继承、多态、虚函数。是面向对象的特性。
- Template C++。C++的泛型编程部分, 也就是所谓的模板元编程。
- STL。STL是个template程序库。它对容器、迭代器、算法及函数对象的规约,并且是以templates及程序库的方式构建出来。
每个层次应该有自己的最佳实践。例如对于C层次,传入函数最佳的实践应该是传入值,而不是指针,而对于C with classes层次,则以传递引用为最佳的实践。
1.2 条款2:尽量以const、enum、inline替换define
- 对于全局的值,使用define定义,在预处理的时候会被替换成相应的值。宏是全局的,面向对象程序设计中破坏了封装。因此在C++中尽量避免它!
由于预处理器会直接替换的原因,宏定义最好用括号括起来。#define函数将会产生出乎意料的结果:
可以使用模板函数来替代:
1.3 条款3:尽可能使用const
这是防卫型(defensive)程序设计的原则, 尽量使用const,从而防止客户错误地使用你的代码。也可直观地让客户得知参数不会被修改。
const与指针的各种用法:
1.4 条款4:确定对象被使用前已被初始化
- 对于内置类型(int,bool,float等),定义的时候一定要初始化,未初始化的值是undefined的。
- 对于非内置类型,需要在构造函数对每一个成员进行初始化。
初始化与赋值是不同的,初始化和赋值对内置类型的成员从效果上没有什么大的区别。对非内置类型成员变量,为了避免两次构造,推荐使用类构造函数初始化列表。const常量则必须在初始化列表中进行初始化:
2. 构造/析构/赋值运算
2.1 条款5:了解C++默默编写并调用哪些函数
如果没有自己编写构造函数,C++会生成一个不带参数的默认构造函数。
在非特殊情况下,C++会自动生成拷贝构造函数、赋值运算符以及析构函数
特殊情况指的是:如果类中有引用类型或者有const类型,此时由于引用类型和const类型不能重新赋值,所以编译器这个时候不会自动生成赋值运算符和拷贝构造函数。
四大函数的调用时机:
- 构造函数:对象定义;使用其他兼容的类型初始化对象时(可使用 explicit 来避免这种情况)
- 复制构造函数:用一个对象来初始化另一对象时;传入对象参数时;返回对象时;
- 析构函数:作用域结束(包括函数返回)时;delete
- =运算符:一个对象赋值给另一对象
|
|
2.2 条款6:若不想使用编译器自动生成的函数,就该明确拒绝
通常在单例中使用会较多,单例不允许发生外部构造、拷贝和=操作。禁用方法是把函数声明为private的,这样,若尝试从外部调用拷贝函数,在编译就会报错。在c++11中,引入了delete关键字,更加严格的限制拷贝函数的生成。
|
|
2.3 条款7:为多态基类声明virtual析构函数
将基类的析构函数声明为virtual,目的在于基类指针调用析构函数时能够正确地析构子类部分的内存。 否则只会析构基类部分,子类部分的内存将会泄漏。
这里又可以引申出虚函数表指针的相关知识,参考链接:https://jocent.me/2017/08/07/virtual-table.html。
|
|
2.4 条款8:别让异常逃离析构函数
不要在析构中抛出异常,由于析构函数常常被自动调用,在析构函数中抛出的异常往往会难以捕获,引发程序非正常退出或未定义行为。
只需为不安全的语句提供一个新的函数,供客户调用和处理异常。
|
|
##2.5 条款9:不要在构造函数和析构函数中调用虚函数
在构造函数中,调用构造函数的顺序是基类->子类,当基类在构造的时候,子类的部分还没有开始构造,这时候,如果调用虚函数,只会调用基类版本的,不符合虚函数的语义。
在析构函数中,调用析构函数的顺序是子类->基类,当基类在析构的时候,子类的部分已经析构完成,这时候,如果调用虚函数,同样只会调用基类版本的,不符合虚函数的语义。
2.6 条款10:令operator=返回一直refrence to *this
用来支持链式的赋值语句:
同理,operator+=等也应该返回refrence to *this
2.7 条款11:在operator=中处理“自我赋值”
考虑到自赋值安全和异常安全。一个更加通用的技术便是复制和交换(copy and swap)
copy and swap策略: https://www.bbsmax.com/A/x9J2o4nN56/
2.8 条款12:复制对象时勿忘其每一个成分
实现拷贝函数时:
- 完整复制当前对象的数据(local data)
- 调用所有父类中对应的拷贝函数
不要让复制构造函数和赋值运算符相互调用,它们的语义完全不同。若不想代码重复,可以抽象到一个普通的方法中,比如init()。
3. 资源管理
3.1 条款13:使用对象来管理资源
比如以下代码:
function 在退出时,需要手动释放 w 的资源,很难避免某些时候忘记释放。
可以使用 RAII ( Resource Acquisition Is Initialization ) —— 资源获取即是初始化,简单的说:
- 在构造时获取资源
- 在析构时释放资源
|
|
或者使用智能指针,无需手动管理资源,std::shared_ptr 本质也是一个RAII。
3.2 条款14:资源管理类要特别注意拷贝行为
使用 RAII 时,要特别注意 RAII 对象的拷贝行为,比如一个 RAII 的互斥锁:
|
|
该互斥锁的使用方法:
|
|
当 m1 被拷贝时,可能会发生死锁的情况,此时应当禁用 Lock 类的拷贝行为,详细参考条款6。
3.3 条款15:资源管理类需要提供对原始资源的访问
可参考 std::shared_ptr 的实现中,提供的 get() 方法,用于访问原始指针。同时也提供了operator->、operator*,让智能指针的表现与直接使用原始指针是一样的。
3.4 条款16: 使用new和delete时采取相同形式
一句话:当你使用 new 来申请内存时,要使用 delete 来释放内存,当你使用 new[] 来申请内存时,要使用 delete[] 来释放内存。
|
|
3.5 条款17:在单独的语句中将new的对象放入智能指针
考虑以下代码:
|
|
以上代码包含三个过程:
- 执行 new Widget
- 调用 priority()
- 构造对象 shared_ptr
()
由于 c++ 编译器的不同,函数参数的调用顺序是不确定的。
若此函数的调用顺序如上述一致,并且在 2 中发生了异常,那么 1 中 new 的对象还没有加入到 shared_ptr 中,会发生内存泄漏。
正确做法:
|
|
4. 设计与声明
4.1 条款18:让接口容易被正确使用,不易被误用
- 明确接口的输入输出类型
- 对于不会被修改的参数,都使用const进行限制
容易被误用的接口:
设计明确的接口:
Month 类可以更加明确的设计:
4.2 条款19:把类(class)的设计视作类型(type)设计
- 这个新的类型如何创建和销毁?new还是new []?
- 初始化和赋值之间又怎样的区别?它们确实是不同的函数调用。
- 如果该类型的对象被传值而不是传引用,意味着怎样的语义?记住:传值时调用的是拷贝构造函数!
- 该类型合理的取值范围是?在你的成员函数、赋值和构造函数中需要做相应的范围检查!
- 你的新类型能融合到继承图中吗?如果你继承自已有的类,你的类将被它们限制(尤其是虚函数限定);如- 果你希望其他类来继承该类型,那么你的方法是否需要声明为virtual?尤其是析构函数。
- 你的新类型允许怎样的类型转换?你可能需要将构造函数声明为explicit来避免隐式类型转换。
- 哪些运算符对你的新类型是有意义的?
- 那些编译器生成的默认方法需要被禁止?
- 谁可以访问你的成员方法?私有、保护、共有成员限定符;友元类、友元函数。
- 你想提供哪些潜在的接口?它们往往关乎异常安全、效率、资源使用等,这些潜在的接口将会影响你的实现。
- 你的类型有多么通用?如果它是非常通用的类型,你可以考虑通过模板把它定义成一系列的类。
- 你真的需要这个新的类型吗?如果你为了扩展一个类而继承了它,那么定义一个非成员函数或者模板能否更好地解决问题?
总结:C++ 真麻烦!
4.3 条款20:pass-by-reference-to-const 比 pass-by-value 更好
- 对于自定义类,建议使用 pass-by-reference-to-const,可以减少多次构造的开销。
- 对与内置类型,使用 pass-by-value 比较合适,因为引用在编译器内部的实现其实是指针,对于内置类型,使用指针多了一层内存访问的过程,相对传值会更慢。
4.4 条款21:需要返回对象时,不要返回引用
两种场景:
返回一个临时变量的引用,由于临时变量在函数退出时会被释放,因此,该函数的返回永远是 null。
返回一个堆空间对象,则容易导致内存泄漏,无法被 delete。
4.5 条款22:将成员变量声明为private
可将成员声明为 private,根据需要,为成员提供 getter 和 setter 方法,达到语法一致和访问控制的目的:
|
|
4.6 条款23:使用非友元非成员函数比成员函数更好
我们在设计函数时,常常会面临成员函数和非成员函数的选择,如下:
|
|
如果我们需要实现一个 clearEverything() 函数,可以有两种方式:
|
|
封装性
面向对象告诉我们,如果数据被越好地封装,那么越少的东西可以看到它,我们便有更大的灵活性去改变它,这样的改变只会影响到有限的客户。
扩展性
C++ 标准库正是这样的做法,将这些非成员函数定义在同一名称空间下,并且按照功能分类分散在不同的文件中。使用时,只需要 include 必要的文件即可,这样就可以降低文件的依赖性,不会导致功能函数变得臃肿:
|
|
4.7 条款 24:用非成员函数来支持所有元的类型转换
如果运算符的所有“元”都需要隐式转换时,请重载该运算符为友元函数。
|
|
考虑以上代码的运算符操作:
|
|
为了让运算符操作跟使用 int 等数字类型实现一样的效果,可将运算符重载为非成员函数(友元函数):
|
|
4.8 条款 25:考虑写一个不抛出异常的 swap 函数
还未能完全理解本条款意思,贴出网上的总结:
- 如果 std::swap 对于你的类型来说是低效的,请提供一个 swap 成员函数,并确保你的 swap 不会抛出异常。
- 如果你提供一个成员 swap,请同时提供一个调用成员swap的非成员swap。对于类(非模板),还要特化 std::swap。
- 调用swap时,请为std::swap使用一个using声明式,然后在调用 swap时不使用任何namespace修饰符。
- 为“用户定义类型”全特化 std 模板是好的,但绝不要尝试在std中加入任何全新的东西。